
昨天結尾停在一個情況:一家公司要的差異大到語系與版面都接不住。那類需求的形狀跟前兩篇不一樣:存檔前多一道信用額度檢查、存檔後把單據同步到另一套系統、刪除時通知倉儲。它們要的不是一份長得不一樣的定義,是一段會執行的行為。
框架原本就有一條路能接:繼承整張表單的 BO,在註冊表裡換掉綁定。但這條路對上面那幾件事過重。為了存檔後發一封通知,得接管整張單據的 BO;三個互不相干的客製需求疊起來,只能寫進同一個子類;套裝日後在那個步驟新增的邏輯還跑不跑得到,取決於子類記不記得呼叫 base。缺的不是擴充點,是一種比繼承輕、而且能逐份客製宣告的掛法。
本篇說明:
能掛的位置開幾個、開在哪裡,是這種機制要答的第一個問題。
框架開了四個:BeforeSave、AfterSave、BeforeDelete、AfterDelete。把它們放回 Day 14 那條寫入管線,位置是這樣:
DoBeforeSave
BeforeSave ← plugin
[擷取變更集]
DoSave ← transaction 只涵蓋這一句
[變更稽核]
DoAfterSave
AfterSave ← plugin
方括號是框架自己做的事,不是擴充點。刪除那一側是同一個形狀:DoBeforeDelete → BeforeDelete → DoDelete → 刪除稽核 → DoAfterDelete → AfterDelete,其中 BeforeDelete 與 AfterDelete 是 plugin 的時點。
| 時點 | 緊接在哪一步之後 | 適合放什麼 |
|---|---|---|
BeforeSave |
DoBeforeSave |
四個裡唯一能安全改資料的 |
AfterSave |
DoAfterSave |
通知、對外同步 |
BeforeDelete |
DoBeforeDelete |
對著整筆快照做的檢查 |
AfterDelete |
DoAfterDelete |
把刪除傳播出去 |
四個時點都在 transaction 之外,所以 Day 14 那條邊界照樣適用:在這裡讀到的值,寫進資料庫的當下不保證還成立。真的要擋住另一個同時在存檔的人,只能讓資料庫在寫入的當下自己判定:一句帶著前提的 UPDATE、一個唯一索引,或一條 check constraint。
每個時點都接在對應的 Do 步驟之後,而那一步最後跑的可能是套裝的 base,也可能是客製 BO 覆寫過的版本。繼承與 plugin 在 Day 14 那條軸線上是相鄰的兩層,差的只是粒度與控制權,兩者因此可以一起用。
BeforeSave 特別的地方在於它排在擷取變更集與寫入之前,所以在那裡改的值會存進資料庫,也會留在稽核軌跡裡。再往後一格就不行了:資料已經寫完,AfterSave 改 DataSet 沒有效果,要改的是呼叫端會拿到的 RefreshedDataSet。這條分界 Day 14 在 SaveContext 上畫過,plugin 拿到的是同一個物件。
刪除那一側的快照也是 Day 14 講過的,這裡只補一句:框架在決定要不要載入它的時候,把有沒有 plugin 也算了進去。因為資料刪掉之後,只剩 AfterDelete 還看得到被刪掉的是什麼。
哪一段客製跑在哪一個時點,這件事要在設定檔上看得見。PluginSettings 因此一列寫兩件事,型別與時點:
<PluginSettings>
<Items>
<ProgramPluginItem ProgId="Order">
<Plugins>
<PluginItem Type="Acme.Plugins.CreditLimitCheck, Acme.Plugins" Stage="BeforeSave" />
<PluginItem Type="Acme.Plugins.OrderSync, Acme.Plugins" Stage="AfterSave" />
</Plugins>
</ProgramPluginItem>
</Items>
</PluginSettings>
一個 ProgId 對一串 plugin,每一個 Stage 都有它自己的類別,一個類別也只掛一個時點。例子裡因此是兩個類別:存檔前那道檢查與存檔後那次同步本來就是兩件事。分開之後順帶換到各自可以被挑走,另一家客戶也在用同一套外部系統,把 OrderSync 宣告上去就好,不必連信用額度檢查一起搬。
類別那一邊要對得上:繼承 FormBusinessPlugin,把基底要的呼叫參數往上傳,覆寫設定檔上宣告的那一個時點。
public sealed class CreditLimitCheck(IBeeContext ctx, Guid accessToken, string progId)
: FormBusinessPlugin(ctx, accessToken, progId)
{
public override void BeforeSave(SaveContext context) { /* 額度不足就丟例外 */ }
}
兩層相加的規則與代價前天已經寫完:套裝鏈先跑、客製鏈接在後面,客製層沒有辦法停用套裝宣告的 plugin。相加之後還有一件事要決定:這條鏈上任何一段失敗了怎麼辦。
兩種失敗都往上拋,不略過、不降級:
| 失敗的種類 | 處置 |
|---|---|
| 宣告的型別載不到 | 直接拋,整個操作失敗 |
| 執行期丟出例外 | 往上拋,中止操作 |
這與 BO 那一軸是同一個答案,理由卻不一樣。那一邊不留退路,是因為退回框架預設的型別照樣建構得起來,那支程式於是安靜地表現得很通用;plugin 這一邊是因為它本來就是有人刻意加上去的,略過它等於客製整段沒有生效,而靜默漏掉一段信用額度檢查,比拒絕存檔難處理得多。Day 15 談註冊表的失敗策略時,判準是「故障的面貌」,這裡是同一條判準的另一個實例。
框架只管執行順序:照設定檔宣告的先後把鏈上的 plugin 叫過一遍,中間不接例外、也不判斷該不該繼續。所以一段 plugin 出錯之後怎麼辦,是寫它的人決定的,而選擇只有兩個:直接拋出,整個操作中止;或是自己接住、記一筆錯誤,流程照常走完。
一般的分法照時點走,Before 拋、After 記。Before 那兩個時點多半在做驗證,擋不下來就不該讓它存進去;After 那兩個時點資料已經提交了,這時丟例外等於對著已經存好的資料回報失敗,而且行程要是在 plugin 跑完之前掛掉,結果是資料在、同步沒發生、也不留痕跡。
After 最常見的用途是把異動同步到另一套系統,那也是最容易寫錯的地方:在那裡拋例外,等於對方系統維護中的時候使用者連單都存不了。框架沒有為這件事加機制,加了也會被一個包住整段的 try-catch 繞過去;它只把責任講明白:別讓外部系統的可用性決定一筆資料能不能存檔。
可靠性要求因此把位置分成兩種:
| 可靠性要求 | 該落在哪裡 |
|---|---|
| 不能漏(財務、庫存、對外承諾) | 在 transaction 內登記一筆待送記錄,另外由背景工作送出 |
| 盡力而為,或另有對帳作業補得回來 | AfterSave / AfterDelete 的 plugin 直接送 |
第二列現在就做得到。第一列還不行:框架裡沒有這樣的實作,而 Day 14 寫過的那六個寫入步驟,也沒有一個能在既有的 transaction 上追加語句。框架說得出這件事該落在哪一邊,但還沒有把那個位置開出來。
一家客戶提出需求的時候,第一個要問的是這件事能不能靠改定義解決。欄位標題換個說法、畫面上少三個欄位、選單照他們的組織排,這幾種前兩篇談過,改的是一份定義,不必動程式。
把常見的需求排在一起,界線就清楚了:
| 想改的東西 | 套裝層 | 客製層 |
|---|---|---|
| 欄位標題、下拉選項的文字 | 改語系檔 | 改客製語系檔 |
| 畫面上少三個欄位、明細換一種排法 | 改版面檔 | 改客製版面檔 |
| 選單長得不一樣 | 改選單檔 | 改客製選單檔 |
| 多一個欄位 | 改 FormSchema 與 TableSchema |
沒有這條路 |
| 改一條計算規則或驗證規則 | 改 FormSchema 裡的算式 |
掛 plugin,或換掉整個 BO |
前三列在兩層都成立,就是 Day 1 那句話:定義是資料,改定義不必重編、不必換組件。第四列只在套裝層成立,理由前天寫過:FormSchema 同時決定資料表長什麼樣,逐份客製分歧的話,實體結構會跟著裂開。
最後一列才是 plugin 的位置。規則與計算式宣告在 FormSchema 裡,而那份定義不能客製,所以一家客戶要改一條算式,只能落回程式碼。開頭那幾個需求也一樣:定義描述的是資料長什麼樣,裡面沒有地方放一段會執行的行為。
落回程式碼之後,plugin 是最輕的那一種:不必接管整張單據、可以疊加、可以逐份客製宣告;但它終究是一個編譯後的型別,要編譯、要打包、要放進主機的 bin 才會生效。同一件事在套裝層是改一行算式,到了客製層就得走這一條交付路徑。「不更版」這件事目前完整成立的範圍是套裝層。
時間拉長還有另一面。一張表單用個幾年,上面會累積好幾個 plugin,而真正會被重複用到的,多半是跟外部系統對接的那幾個:把客戶資料同步到 CRM、把單據送進 BPM 簽核、把交期寫進 Outlook 或 Google 行事曆。這一種綁的是對方系統,不是某一家客戶,所以新客戶只要也在用那套系統,把它宣告上去就能用,等於在既有的清單裡挑幾列。
各家自己的業務規則沒有這個性質。信用額度怎麼算、單號怎麼配,多半一家一個樣,寫了通常也只有那一家在用。但這一類累積起來仍然值得定期盤一次:如果好幾家都寫了同一種行為,那就不是客製,是還沒被收斂的共通需求。
plugin 是繼承與定義檔之間的那一格。不接管整條流程,只在四個固定的位置各加一段;兩層相加,套裝日後補的自動生效;一個類別一個時點,設定檔上讀得出哪一段跑在哪裡。
BeforeSave 是唯一能安全改資料的位置After 時點承接得了的是盡力而為、或另有對帳作業補得回來的那一類這一章三篇問的是同一件事的三個面向:一家公司要不一樣的地方,最後被表達成什麼。表達成定義,它就能疊加、能退回、能跟著套裝一起演進;表達成型別,它就得編譯、打包、進到主機的 bin,然後回到 Day 1 那條「需求的大小,與交付的成本,徹底脫鉤」的老路上。
所以判準不是這個擴充機制能做多少事,是它要走哪一條交付路徑。plugin 把客製的門檻從「接管一整張單據」降到「加一個類別」,這是實質的一級,但它跨不過定義與程式碼之間那條線。客製化這條線接下來要往前推的,就是把更多「不一樣」從程式碼那一側搬回定義這一側。
明天起換一個層面,先從一份物件同時要能存成檔案、送給瀏覽器、又要在行動端讀得回來這件事開始。
本系列同步發表於 HackMD,完整目錄